Proyecto final - Análisis de Toxicidad en Tweets

Author

Patricio Porras

1. Carga y Comprensión de datos

En esta primera fase, importaremos las librerías necesarias, cargaremos el conjunto de datos y realizaremos un Análisis Exploratorio de Datos (EDA) para entender la estructura, calidad y características de la información con la que trabajaremos.

Importar librerías

print("Importar librerías")

# Manipulación de datos
import altair as alt
import pandas as pd
import numpy as np

# Procesamiento de Texto (NLP)
import spacy
import nltk

from nltk.corpus import stopwords
from nltk import word_tokenize # tokenizacion
from nltk import pos_tag #lematizacion
from nltk.stem import WordNetLemmatizer
from nltk.corpus import wordnet

# Procesamiento de texto y features
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder, LabelEncoder, StandardScaler
from sklearn.feature_extraction.text import TfidfVectorizer, CountVectorizer
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline

nltk.download('stopwords') # necessary for removal of stop words
nltk.download('wordnet') # necessary for lemmatization

# Modelo de clasificación
from sklearn.linear_model import LogisticRegression, Ridge

# Métricas de Evaluación
from sklearn.metrics import (
    classification_report, 
    confusion_matrix, 
    ConfusionMatrixDisplay,
    roc_auc_score, 
    RocCurveDisplay,
    mean_squared_error, 
    r2_score,
    silhouette_score
)

# Visualización
import matplotlib.pyplot as plt
import seaborn as sns

# Configuración de visualización
sns.set_style("whitegrid")
plt.rcParams['figure.figsize'] = (10, 6)

from sklearn.cluster import KMeans
from sklearn.datasets import fetch_openml
from sklearn.metrics import silhouette_score

import re
import unicodedata

# Visualización
import matplotlib.pyplot as plt
import seaborn as sns

# Visualización de textos
from wordcloud import WordCloud

# Configuraciones
import warnings
warnings.filterwarnings('ignore')

# Descargar recursos de NLTK (stopwords)
nltk.download('stopwords', quiet=True)

# Cargar modelo de SpaCy para español (para lematización)
!python -m spacy download es_core_news_sm -q
nlp_spacy = spacy.load('es_core_news_sm')
Importar librerías
[nltk_data] Downloading package stopwords to
[nltk_data]     C:\Users\Lenovo\AppData\Roaming\nltk_data...
[nltk_data]   Package stopwords is already up-to-date!
[nltk_data] Downloading package wordnet to
[nltk_data]     C:\Users\Lenovo\AppData\Roaming\nltk_data...
[nltk_data]   Package wordnet is already up-to-date!
[+] Download and installation successful

You can now load the package via spacy.load('es_core_news_sm')

Cargar dataset

print("Cargar dataset")

url = "https://raw.githubusercontent.com/erickedu85/dataset/refs/heads/master/tweets/1500_tweets_con_toxicity.csv"
df = pd.read_csv(url)
Cargar dataset

Función de Limpieza de Texto (NLP)

Definimos la función personalizada que realizará la limpieza y lematización del texto en español, según lo solicitado.

# Obtenemos stopwords en español
stopwords_es = set(stopwords.words('spanish'))

def limpiar_y_lematizar(texto):
    """
    Función completa para limpiar y lematizar texto en español.
    1. Reemplaza tildes y caracteres especiales.
    2. Convierte a minúsculas.
    3. Elimina URLs, menciones (@) y hashtags (#).
    4. Elimina puntuación y números.
    5. Lematiza con SpaCy y elimina stopwords.
    """
    if not isinstance(texto, str):
        return ""
    
    # 1. Reemplazar tildes (Normalización NFD)
    texto = unicodedata.normalize('NFD', texto).encode('ascii', 'ignore').decode('utf-8')
    
    # 2. Convertir a minúsculas
    texto = texto.lower()
    
    # 3. Eliminar URLs, menciones y hashtags
    texto = re.sub(r'http\S+|www\S+|https\S+', '', texto, flags=re.MULTILINE)
    texto = re.sub(r'[@#]\w+', '', texto)
    
    # 4. Eliminar puntuación y números
    texto = re.sub(r'[^a-zA-Z\s]', '', texto)
    
    # 5. Lematización con SpaCy y eliminación de stopwords
    doc = nlp_spacy(texto)
    lemmas = [
        token.lemma_ 
        for token in doc 
        if token.text not in stopwords_es and 
           not token.is_punct and 
           not token.is_space and 
           len(token.text) > 2 # eliminar tokens muy cortos
    ]
    
    return " ".join(lemmas)

# --- Prueba de la función ---
texto_ejemplo = df['content'].iloc[0]
print(f"--- Texto Original ---\n{texto_ejemplo}\n")
print(f"--- Texto Limpio y Lematizado ---\n{limpiar_y_lematizar(texto_ejemplo)}")
--- Texto Original ---
@DanielNoboaOk @DiegoBorjaPC Lávate el hocico presidente de cartón,habla la verdad y cómo son las cosas! Cómo han sido los tiempos y cómo han pasado las cosas,si no entiendes cómo son los procesos de contratación qué haces de presidente ignorante!Borja no debería ser candidato es correcto al tener un vínculo

--- Texto Limpio y Lematizado ---
lavatar hocico presidente cartonhabla verdad cosa ser tiempo pasar cosassi entiend proceso contratacion hacer presidente ignoranteborgir deberio ser candidato correcto tener vinculo

EDA

Exploramos la estructura, los tipos de datos, los valores nulos y las distribuciones de las variables clave.

Primeras filas del dataset

print("Primeras filas del dataset")

# Visualizar las primeras 5 filas
df.head()
Primeras filas del dataset
tweetId tweetUrl content isReply replyTo createdAt authorId authorName authorUsername authorVerified ... inReplyToId Date time_response account_age_days mentions_count hashtags_count content_length has_profile_picture sentiment_polarity toxicity_score
0 1878630970745900800 https://x.com/Pableins15/status/1878630970745901259 @DanielNoboaOk @DiegoBorjaPC Lávate el hocico presidente de cartón,habla la verdad y cómo son las cosas! Cómo han sido los tiempos y cómo han pasado las cosas,si no entiendes cómo son los procesos... True DanielNoboaOk 2025-01-13 02:31:00 176948611 Pablo Balarezo Pableins15 False ... 1878539079249547520 2025-01-12 20:26:32 364.466667 5261 2 0 309 False 0.0 0.543256
1 1904041877503984128 https://x.com/solma1201/status/1904041877503983746 @DanielNoboaOk De esa arrastrada no te levantas nunca... Chao cartón 📉 True DanielNoboaOk 2025-03-24 05:25:00 1368663286582030336 Solma1201 solma1201 False ... 1904003201143115776 2025-03-24 02:51:52 153.133333 1399 1 0 70 True 0.0 0.426917
2 1877463444649046016 https://x.com/Mediterran67794/status/1877463444649046092 @LuisaGonzalezEc @RC5Oficial Protegiendo a los narcotraficantes, criminales, violadores, delincuentes, locos y prostitutas True LuisaGonzalezEc 2025-01-09 21:12:00 1851005619106451712 Médico Escritor Filósofo Hermeneútico Mediterran67794 False ... 1877158437236228352 2025-01-09 01:00:22 1211.633333 68 2 0 122 True 0.0 0.555970
3 1881356046108885248 https://x.com/ardededa/status/1881356046108885494 @DanielNoboaOk #NoboaPresidente. Todo 7! True DanielNoboaOk 2025-01-20 15:00:00 315799544 Denise ardededa False ... 1881165128185560832 2025-01-20 02:21:31 758.483333 4955 1 0 41 True 0.0 0.046615
4 1888331962063978752 https://x.com/LMarquinezm/status/1888331962063978998 @slider1908 @LuisaGonzalezEc @DianaAtamaint @cnegobec @FFAAECUADOR Troll de mierda a callarse la boca True slider1908 2025-02-08 20:59:00 1551883554 Luis Marquínez LMarquinezm False ... 1888256000085397504 2025-02-08 14:59:07 359.883333 4208 5 0 101 True 0.0 0.846027

5 rows × 27 columns

Información del dataset

print("Información del dataset")

# Información general del dataset
df.info()
Información del dataset
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1500 entries, 0 to 1499
Data columns (total 27 columns):
 #   Column               Non-Null Count  Dtype  
---  ------               --------------  -----  
 0   tweetId              1500 non-null   int64  
 1   tweetUrl             1500 non-null   object 
 2   content              1500 non-null   object 
 3   isReply              1500 non-null   bool   
 4   replyTo              1490 non-null   object 
 5   createdAt            1500 non-null   object 
 6   authorId             1500 non-null   int64  
 7   authorName           1500 non-null   object 
 8   authorUsername       1500 non-null   object 
 9   authorVerified       1500 non-null   bool   
 10  authorFollowers      1500 non-null   int64  
 11  authorProfilePic     1500 non-null   object 
 12  authorJoinDate       1500 non-null   object 
 13  source               1500 non-null   object 
 14  hashtags             121 non-null    object 
 15  mentions             1499 non-null   object 
 16  conversationId       1500 non-null   int64  
 17  inReplyToId          1500 non-null   int64  
 18  Date                 1500 non-null   object 
 19  time_response        1500 non-null   float64
 20  account_age_days     1500 non-null   int64  
 21  mentions_count       1500 non-null   int64  
 22  hashtags_count       1500 non-null   int64  
 23  content_length       1500 non-null   int64  
 24  has_profile_picture  1500 non-null   bool   
 25  sentiment_polarity   1500 non-null   float64
 26  toxicity_score       1347 non-null   float64
dtypes: bool(3), float64(3), int64(9), object(12)
memory usage: 285.8+ KB

Detección de Valores Nulos

Identificamos las columnas con datos faltantes.

print("Conteo de valores nulos por columna:")
null_counts = df.isnull().sum()
null_counts_percent = (null_counts / len(df) * 100).round(2)
null_summary = pd.DataFrame({'conteo_nulos': null_counts, 'porcentaje_nulos': null_counts_percent})
print(null_summary[null_summary['conteo_nulos'] > 0])
Conteo de valores nulos por columna:
                conteo_nulos  porcentaje_nulos
replyTo                   10              0.67
hashtags                1379             91.93
mentions                   1              0.07
toxicity_score           153             10.20

Hallazgo Clave 1:

La variable toxicity_score, que es nuestro target principal, tiene 153 valores nulos (10.2% del dataset). Para los modelos supervisados (regresión y clasificación), vamos a eliminar estas filas, ya que no podemos entrenar sin una etiqueta.

Análisis del Target: toxicity_score

Analizamos la distribución de nuestra variable objetivo principal.

Analisis sin excluir valores nulos, representación en barras

alt.Chart(df).mark_bar().encode(
    x=alt.X('toxicity_score:Q', bin=True, title='Nivel de Toxicidad'),
    y=alt.Y('count():Q', title='Frecuencia'),
    tooltip=['toxicity_score:Q', 'count():Q']
).properties(
    title='Distribución de Toxicity Score'
).interactive()

Distribución completa del ‘toxicity_score’

Analisis sin excluir valores nulos, representación en círculos

alt.Chart(df).mark_circle().encode(
    alt.X('toxicity_score'),
    alt.Y('count()'),
    tooltip=['toxicity_score', 'count()']
).properties(
    title='Distribución de Toxicity Score'
).interactive()

Distribución completa del ‘toxicity_score’

Análisis descriptivo de: toxicity_score.

print("Estadísticas descriptivas de 'toxicity_score':")
print(df['toxicity_score'].describe())
Estadísticas descriptivas de 'toxicity_score':
count    1347.000000
mean        0.253879
std         0.243942
min         0.001940
25%         0.028444
50%         0.188392
75%         0.426917
max         0.939145
Name: toxicity_score, dtype: float64

Analisis sin valores nulos

# Gráfico interactivo con Altair
chart = alt.Chart(df.dropna(subset=['toxicity_score'])).mark_bar().encode(
    x=alt.X('toxicity_score', bin=alt.Bin(maxbins=50), title='Nivel de Toxicidad'),
    y=alt.Y('count()', title='Frecuencia'),
    tooltip=[alt.X('toxicity_score', bin=alt.Bin(maxbins=50)), 'count()']
).properties(
    title='Distribución de Toxicity Score'
)

density = alt.Chart(df.dropna(subset=['toxicity_score'])).transform_density(
    'toxicity_score',
    as_=['toxicity_score', 'density'],
).mark_line(color='red').encode(
    x=alt.X('toxicity_score', title='Nivel de Toxicidad'),
    y=alt.Y('density:Q', title='Densidad'),
)

# Combinar histograma y densidad (en diferentes ejes Y)
# (Para combinar en el mismo gráfico necesitarían escalas normalizadas,
# pero para exploración visual, dos gráficos alineados son efectivos.)

display(chart + density)

Distribución del ‘toxicity_score’

Hallazgo Clave 2:

La distribución de toxicity_score está fuertemente sesgada a la derecha (cola larga hacia valores altos), pero la gran mayoría de los tweets tiene un score de toxicidad bajo (cercano a 0). Esto es fundamental para la clasificación: si usamos un umbral fijo como 0.5, las clases resultarán muy desbalanceadas.

Análisis de Features Relevantes

Exploramos las variables que usaremos como features (predictoras).

print("Sin valores nulos")
df_clean = df.dropna(subset=['toxicity_score'])
Sin valores nulos
print("Distribución de Variables Numéricas:")

numeric_features = ['authorFollowers', 'time_response', 'account_age_days', 
                  'mentions_count', 'hashtags_count', 'content_length']

# Creamos una figura con 2 filas y 3 columnas
fig, axes = plt.subplots(nrows=2, ncols=3, figsize=(18, 10))
fig.suptitle('Distribución de Features Numéricos', fontsize=16, y=1.02)

# Aplanamos el array de ejes para iterar fácilmente
axes = axes.flatten()

for i, col in enumerate(numeric_features):
    sns.histplot(data=df_clean, x=col, kde=True, ax=axes[i])
    axes[i].set_title(f'Distribución de {col}')
    
    # Detección de sesgo alto: 'authorFollowers' y 'time_response'
    # Si están muy sesgadas, una escala logarítmica ayuda a visualizar
    if col in ['authorFollowers', 'time_response']:
        axes[i].set_xscale('log')
        axes[i].set_title(f'Distribución de {col} (Escala Log)')

plt.tight_layout()
plt.show()
Distribución de Variables Numéricas:

Histogramas de variables numéricas
print("Boxplots de Variables Numéricas (para detectar outliers):")

# Creamos una figura con 2 filas y 3 columnas
fig, axes = plt.subplots(nrows=2, ncols=3, figsize=(18, 10))
fig.suptitle('Boxplots de Features Numéricos', fontsize=16, y=1.02)

# Aplanamos el array de ejes
axes = axes.flatten()

for i, col in enumerate(numeric_features):
    sns.boxplot(data=df_clean, x=col, ax=axes[i])
    axes[i].set_title(f'Boxplot de {col}')
    
    # Aplicamos escala logarítmica a las mismas variables sesgadas
    if col in ['authorFollowers', 'time_response']:
        axes[i].set_xscale('log')
        axes[i].set_title(f'Boxplot de {col} (Escala Log)')

plt.tight_layout()
plt.show()
Boxplots de Variables Numéricas (para detectar outliers):

Boxplots de variables numéricas
print("Conteo de Variables Categóricas:")

categorical_features = ['isReply', 'authorVerified', 'has_profile_picture', 'source']

# Creamos una figura con 2 filas y 2 columnas
fig, axes = plt.subplots(nrows=2, ncols=2, figsize=(15, 12))
fig.suptitle('Conteo de Features Categóricos', fontsize=16, y=1.02)

# isReply
sns.countplot(data=df_clean, x='isReply', ax=axes[0, 0])
axes[0, 0].set_title('Conteo de "isReply"')

# authorVerified
sns.countplot(data=df_clean, x='authorVerified', ax=axes[0, 1])
axes[0, 1].set_title('Conteo de "authorVerified"')

# has_profile_picture
sns.countplot(data=df_clean, x='has_profile_picture', ax=axes[1, 0])
axes[1, 0].set_title('Conteo de "has_profile_picture"')

# --- Tratamiento especial para 'source' ---
# Obtenemos el Top 10 de 'source'
top_10_sources = df_clean['source'].value_counts().nlargest(10).index

# Graficamos 'source' (Top 10) de forma horizontal para mejor lectura
sns.countplot(data=df_clean, y='source', order=top_10_sources, ax=axes[1, 1])
axes[1, 1].set_title('Top 10 de "source" (Plataforma)')
axes[1, 1].set_xlabel('Conteo')
axes[1, 1].set_ylabel('Source')


plt.tight_layout()
plt.show()
Conteo de Variables Categóricas:

Conteo de variables categóricas
# Variables Numéricas
numeric_features_list = ['authorFollowers', 'time_response', 'account_age_days', 'mentions_count', 'hashtags_count', 'content_length']
print("Estadísticas de Features Numéricos:")
display(df[numeric_features_list].describe())

# Variables Categóricas
categorical_features_list = ['isReply', 'authorVerified', 'has_profile_picture', 'source']
print("\nConteo de valores en Features Categóricos:")
for col in categorical_features_list:
    print(f"\n--- {col} ---")
    print(df[col].value_counts(normalize=True).head(10)) # .head(10) para 'source'
Estadísticas de Features Numéricos:
authorFollowers time_response account_age_days mentions_count hashtags_count content_length
count 1.500000e+03 1500.000000 1500.000000 1500.000000 1500.0 1500.000000
mean 3.625721e+03 1170.037578 2271.134000 1.723333 0.0 116.528000
std 1.184447e+05 3273.929808 1984.156805 0.946248 0.0 77.493712
min 0.000000e+00 0.133333 -90.000000 0.000000 0.0 17.000000
25% 7.000000e+00 136.300000 455.750000 1.000000 0.0 57.000000
50% 4.300000e+01 515.625000 1538.000000 2.000000 0.0 96.000000
75% 1.992500e+02 1265.716667 4420.750000 2.000000 0.0 150.000000
max 4.577730e+06 63569.000000 6506.000000 10.000000 0.0 684.000000

Conteo de valores en Features Categóricos:

--- isReply ---
isReply
True    1.0
Name: proportion, dtype: float64

--- authorVerified ---
authorVerified
False    1.0
Name: proportion, dtype: float64

--- has_profile_picture ---
has_profile_picture
True     0.955333
False    0.044667
Name: proportion, dtype: float64

--- source ---
source
Twitter for iPhone    1.0
Name: proportion, dtype: float64

Hallazgo Clave 3:

  • Features Numéricos: Las variables tienen escalas muy diferentes (ej. authorFollowers vs mentions_count), lo que confirma la necesidad de escalamiento (ej. StandardScaler).
  • Features Categóricos: source Todos los registros de esta muestra fueron realizados desde Twitter for iPhone. OneHotEncoder es apropiado. isReply y authorVerified son booleanos que también serán codificados.

Generando Nube de Palabras del texto limpio…

print("Generando Nube de Palabras del texto limpio...")

# 1. Aplicamos la limpieza (lematización, stopwords, etc.)
# Esto puede tardar un momento
text_limpio = df_clean['content'].apply(limpiar_y_lematizar)

# 2. Unimos todo el texto en un solo string
full_text = " ".join(text_limpio)

# 3. Generamos la nube de palabras
wordcloud = WordCloud(width=1200, height=600, 
                      background_color='white', 
                      colormap='viridis',
                      max_words=150
                     ).generate(full_text)

# 4. Mostramos la imagen
plt.figure(figsize=(15, 7))
plt.imshow(wordcloud, interpolation='bilinear')
plt.axis('off')
plt.title('Nube de Palabras más Frecuentes (Lematizadas)', fontsize=16)
plt.show()
Generando Nube de Palabras del texto limpio...

Nube de palabras del contenido de los tweets

Gráfico de Frecuencias (Top 20 Palabras)

print("Generando Gráfico de Frecuencias del texto limpio...")

# Usamos CountVectorizer con nuestra función de limpieza
vec = CountVectorizer(preprocessor=limpiar_y_lematizar)

# Obtenemos la matriz de conteo
text_counts = vec.fit_transform(df_clean['content'])

# Sumamos las ocurrencias de cada palabra
sum_words = text_counts.sum(axis=0) 
words_freq = [(word, sum_words[0, idx]) for word, idx in vec.vocabulary_.items()]
words_freq = sorted(words_freq, key = lambda x: x[1], reverse=True)

# Creamos un DataFrame con el Top 20
top_words_df = pd.DataFrame(words_freq[:20], columns=['Palabra', 'Frecuencia'])

# Graficamos
plt.figure(figsize=(15, 8))
sns.barplot(data=top_words_df, x='Frecuencia', y='Palabra', palette='plasma')
plt.title('Top 20 Palabras más Frecuentes (Lematizadas)')
plt.xlabel('Frecuencia Total')
plt.ylabel('Palabra')
plt.show()
Generando Gráfico de Frecuencias del texto limpio...

Top 20 palabras más frecuentes (lematizadas)

Heatmap de Correlación (Features Numéricos y Target)

print("Heatmap de Correlación (Features Numéricos y Target)")

# Seleccionamos solo las columnas numéricas y el target
numeric_and_target = numeric_features + ['toxicity_score']
df_corr = df_clean[numeric_and_target]

# Calculamos la matriz de correlación
corr_matrix = df_corr.corr()

# Graficamos el heatmap
plt.figure(figsize=(12, 8))
sns.heatmap(corr_matrix, 
            annot=True,     # Mostrar los valores numéricos
            cmap='vlag',    # Paleta de colores (rojo-blanco-azul)
            fmt=".2f",      # Formato con 2 decimales
            linewidths=0.5)
plt.title('Heatmap de Correlación (Spearman)', fontsize=16)
plt.show()
Heatmap de Correlación (Features Numéricos y Target)

Heatmap de correlación

Gráfico de Correlación Específico (content_length vs toxicity_score)

print("Heatmap de Densidad entre Longitud del Contenido y Toxicidad")

plt.figure(figsize=(12, 8))
sns.histplot(data=df_clean, x='content_length', y='toxicity_score', bins=50, cbar=True)
plt.title('Heatmap de Densidad: Longitud vs. Toxicidad')
plt.xlabel('Longitud del Contenido (Caracteres)')
plt.ylabel('Score de Toxicidad')
plt.show()
Heatmap de Densidad entre Longitud del Contenido y Toxicidad

Heatmap de Densidad: Longitud del Tweet vs. Score de Toxicidad

2. Preprocesamiento de datos

En esta sección, preparamos los datos para el modelado:

  1. Manejamos los nulos del target.
  2. Definimos las variables X (features) e y (targets).
  3. Creamos la función de limpieza de texto (NLP) que incluye lematización.

Limpieza de Nulos y Creación de Targets

Como se decidió en el EDA, eliminamos las filas donde toxicity_score es nulo para los modelos supervisados.

print(f"Tamaño original: {df.shape}")
df_clean = df.dropna(subset=['toxicity_score'])
print(f"Tamaño después de eliminar nulos en target: {df_clean.shape}")

# Definición de Targets
# 1. Target de Regresión (continuo)
y_reg = df_clean['toxicity_score']

# 2. Target de Clasificación (binario)
# Usamos el umbral de 0.5 como se solicitó.
UMBRAL_TOXICIDAD = 0.5
y_class = (df_clean['toxicity_score'] > UMBRAL_TOXICIDAD).astype(int)

# Revisamos el desbalanceo de clases (esperado por el EDA)
print("\nBalance de clases para el target binario (umbral > 0.5):")
print(y_class.value_counts(normalize=True))
Tamaño original: (1500, 27)
Tamaño después de eliminar nulos en target: (1347, 27)

Balance de clases para el target binario (umbral > 0.5):
toxicity_score
0    0.819599
1    0.180401
Name: proportion, dtype: float64

Nota sobre Desbalanceo: Como se anticipó, la clase 1 (tóxico) representa solo el 24% de los datos. Usaremos class_weight='balanced' en el modelo de clasificación para mitigar esto.

Definición de Features (X) y Targets (y)

Seleccionamos las columnas que servirán como features.

# Columnas de features identificadas en el EDA
text_features = 'content'
numeric_features = ['authorFollowers', 'time_response', 'account_age_days', 'mentions_count', 'hashtags_count', 'content_length']
categorical_features = ['isReply', 'authorVerified', 'has_profile_picture', 'source']

# Rellenamos nulos en 'source' (feature categórico) con 'Desconocido'
# (Aunque en este dataset no parece haber nulos en 'source' tras limpiar el target, es buena práctica)
df_clean['source'] = df_clean['source'].fillna('Desconocido')

# Creamos el DataFrame X de features
X = df_clean[numeric_features + categorical_features + [text_features]]

print(f"Dimensiones de X (features): {X.shape}")
print(f"Dimensiones de y_reg (target regresión): {y_reg.shape}")
print(f"Dimensiones de y_class (target clasificación): {y_class.shape}")
Dimensiones de X (features): (1347, 11)
Dimensiones de y_reg (target regresión): (1347,)
Dimensiones de y_class (target clasificación): (1347,)

3. División de datos

Dividimos los datos en conjuntos de entrenamiento (train) y prueba (test) para poder evaluar nuestros modelos de forma objetiva.

Separación de features y target

Ya tenemos X (features) e y_reg / y_class (targets) definidos en la sección anterior.

Split

Usamos train_test_split para crear las divisiones. Es crucial dividir X, y_reg e y_class simultáneamente para mantener la alineación de los índices.

# Dividimos los datos
X_train, X_test, y_train_reg, y_test_reg, y_train_class, y_test_class = train_test_split(
    X, 
    y_reg, 
    y_class, 
    test_size=0.25,  # 25% para test
    random_state=42,
    stratify=y_class # Estratificamos por el target de clasificación para mantener la proporción
)

print(f"Tamaño X_train: {X_train.shape}")
print(f"Tamaño X_test: {X_test.shape}")
print(f"Tamaño y_train_reg: {y_train_reg.shape}")
print(f"Tamaño y_test_class: {y_test_class.shape}")
Tamaño X_train: (1010, 11)
Tamaño X_test: (337, 11)
Tamaño y_train_reg: (1010,)
Tamaño y_test_class: (337,)

4. Entrenamiento del modelo

Aquí definimos el ColumnTransformer y entrenamos los tres modelos solicitados.

Pipeline (ColumnTransformer)

Creamos el pipeline de preprocesamiento principal usando ColumnTransformer. Este se encargará de aplicar las transformaciones correctas a cada tipo de columna (numérica, categórica y texto).

# 1. Pipeline para Features Numéricos
numeric_transformer = Pipeline(steps=[
    ('scaler', StandardScaler())
])

# 2. Pipeline para Features Categóricos
categorical_transformer = Pipeline(steps=[
    ('onehot', OneHotEncoder(handle_unknown='ignore', sparse_output=False))
])

# 3. Pipeline para Features de Texto
# Pasamos nuestra función personalizada al preprocesador de TfidfVectorizer
text_transformer = Pipeline(steps=[
    ('tfidf', TfidfVectorizer(preprocessor=limpiar_y_lematizar))
])

# 4. Combinar todo en el ColumnTransformer
preprocessor = ColumnTransformer(
    transformers=[
        ('num', numeric_transformer, numeric_features),
        ('cat', categorical_transformer, categorical_features),
        ('text', text_transformer, text_features)
    ],
    remainder='drop' # Ignora columnas no especificadas
)

print("ColumnTransformer definido exitosamente.")
ColumnTransformer definido exitosamente.

Tarea 1: Regresión (Ridge)

Construimos el pipeline final (preprocesador + modelo) y lo entrenamos para la tarea de regresión.

print("Construir pipeline para Regresión (Ridge)")
# Creamos el pipeline completo
pipeline_reg = Pipeline(steps=[
    ('preprocessor', preprocessor),
    ('model', Ridge(random_state=42))
])
Construir pipeline para Regresión (Ridge)

Entrenamos el modelo de regresión

print("Entrenando modelo de Regresión (Ridge)...")

pipeline_reg.fit(X_train, y_train_reg)
Entrenando modelo de Regresión (Ridge)...
Pipeline(steps=[('preprocessor',
                 ColumnTransformer(transformers=[('num',
                                                  Pipeline(steps=[('scaler',
                                                                   StandardScaler())]),
                                                  ['authorFollowers',
                                                   'time_response',
                                                   'account_age_days',
                                                   'mentions_count',
                                                   'hashtags_count',
                                                   'content_length']),
                                                 ('cat',
                                                  Pipeline(steps=[('onehot',
                                                                   OneHotEncoder(handle_unknown='ignore',
                                                                                 sparse_output=False))]),
                                                  ['isReply', 'authorVerified',
                                                   'has_profile_picture',
                                                   'source']),
                                                 ('text',
                                                  Pipeline(steps=[('tfidf',
                                                                   TfidfVectorizer(preprocessor=<function limpiar_y_lematizar at 0x000001FDC91B1260>))]),
                                                  'content')])),
                ('model', Ridge(random_state=42))])
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.

Tarea 2: Clasificación (LogisticRegression)

Construimos el pipeline para clasificación. Usamos class_weight='balanced' para manejar el desbalanceo de clases.

# Creamos el pipeline completo
pipeline_class = Pipeline(steps=[
    ('preprocessor', preprocessor),
    ('model', LogisticRegression(random_state=42, class_weight='balanced', max_iter=1000))
])

Entrenamos el modelo de clasificación

print("Entrenando modelo de Clasificación (LogisticRegression)")
pipeline_class.fit(X_train, y_train_class)
Entrenando modelo de Clasificación (LogisticRegression)
Pipeline(steps=[('preprocessor',
                 ColumnTransformer(transformers=[('num',
                                                  Pipeline(steps=[('scaler',
                                                                   StandardScaler())]),
                                                  ['authorFollowers',
                                                   'time_response',
                                                   'account_age_days',
                                                   'mentions_count',
                                                   'hashtags_count',
                                                   'content_length']),
                                                 ('cat',
                                                  Pipeline(steps=[('onehot',
                                                                   OneHotEncoder(handle_unknown='ignore',
                                                                                 sparse_output=False))]),
                                                  ['isReply', 'authorVerified',
                                                   'has_profile_picture',
                                                   'source']),
                                                 ('text',
                                                  Pipeline(steps=[('tfidf',
                                                                   TfidfVectorizer(preprocessor=<function limpiar_y_lematizar at 0x000001FDC91B1260>))]),
                                                  'content')])),
                ('model',
                 LogisticRegression(class_weight='balanced', max_iter=1000,
                                    random_state=42))])
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.

Tarea 3: Clustering (KMeans)

Para el clustering sobre texto, seguimos un enfoque ligeramente diferente:

  1. Creamos un preprocesador que solo extrae y vectoriza el texto.
  2. Buscamos el k óptimo usando el Coeficiente de Silueta.
  3. Entrenamos el modelo KMeans final con el k óptimo.

Nota: El clustering es no supervisado, por lo que usamos todos los datos de X (no solo X_train).

Búsqueda de K Óptimo

# 1. Creamos el vectorizador de texto
tfidf_vectorizer = TfidfVectorizer(preprocessor=limpiar_y_lematizar, max_features=1000)

# 2. Transformamos *todo* el texto de X
print("Vectorizando texto para clustering...")
X_text_tfidf = tfidf_vectorizer.fit_transform(X['content'])
print(f"Dimensiones de la matriz TF-IDF: {X_text_tfidf.shape}")

# 3. Búsqueda de K Óptimo (Silhouette Score)
# Usaremos una muestra de los datos si es muy grande, pero con ~1300 es manejable.
silhouette_scores = []
range_n_clusters = range(2, 11) # Probamos de 2 a 10 clusters

print("Calculando Coeficiente de Silueta para K de 2 a 10...")
for k in range_n_clusters:
    kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)
    cluster_labels = kmeans.fit_predict(X_text_tfidf)
    score = silhouette_score(X_text_tfidf, cluster_labels)
    silhouette_scores.append(score)
    print(f"K={k}, Silhouette Score={score:.4f}")

# Graficamos los resultados
plt.figure(figsize=(10, 6))
plt.plot(range_n_clusters, silhouette_scores, 'bo-', markersize=8)
plt.xlabel('Número de Clusters (k)')
plt.ylabel('Coeficiente de Silueta')
plt.title('Método de la Silueta para encontrar K Óptimo')
plt.grid(True)
plt.show()

# Seleccionamos el K óptimo
k_optimo = range_n_clusters[np.argmax(silhouette_scores)]
print(f"\nEl K óptimo (mayor score de silueta) es: {k_optimo}")
Vectorizando texto para clustering...
Dimensiones de la matriz TF-IDF: (1347, 1000)
Calculando Coeficiente de Silueta para K de 2 a 10...
K=2, Silhouette Score=0.0377
K=3, Silhouette Score=0.0390
K=4, Silhouette Score=0.0373
K=5, Silhouette Score=0.0373
K=6, Silhouette Score=0.0345
K=7, Silhouette Score=0.0344
K=8, Silhouette Score=0.0313
K=9, Silhouette Score=0.0345
K=10, Silhouette Score=0.0379

Método de la Silueta para K Óptimo

El K óptimo (mayor score de silueta) es: 3

Entrenamiento de KMeans

Entrenamos el modelo final de KMeans con el k_optimo encontrado.

kmeans = KMeans(n_clusters=k_optimo, random_state=42, n_init=10)
print(f"Entrenando KMeans con k={k_optimo}...")
cluster_labels = kmeans.fit_predict(X_text_tfidf)
print("Clustering completado.")

# Añadimos los labels del cluster al DataFrame limpio para análisis posterior
df_clean['cluster'] = cluster_labels
Entrenando KMeans con k=3...
Clustering completado.

5. Predicciones

Usamos los modelos entrenados para generar predicciones sobre el conjunto de prueba (X_test).

Predicciones de Regresión

print("Generar predicciones de regresión.")

y_pred_reg = pipeline_reg.predict(X_test)
Generar predicciones de regresión.

Predicciones de Clasificación

Generamos tanto las clases predichas como las probabilidades (necesarias para la curva ROC).

print("Generndo predicciones de clasificación.")

y_pred_class = pipeline_class.predict(X_test)
y_pred_proba_class = pipeline_class.predict_proba(X_test)[:, 1] # Probabilidad de la clase 1 (tóxico)
Generndo predicciones de clasificación.

Predicciones de Clustering

Las “predicciones” del clustering son las etiquetas asignadas a cada punto de dato, las cuales ya se calcularon y almacenaron en df_clean['cluster'] en el paso anterior.


6. Evaluaciones del modelo

Evaluamos el rendimiento de cada una de nuestras tres tareas.

Tarea 1: Evaluación de Regresión (Ridge)

Evaluamos qué tan bien nuestro modelo predice el score continuo de toxicidad.

Métricas (RMSE y R²)

rmse = np.sqrt(mean_squared_error(y_test_reg, y_pred_reg))
r2 = r2_score(y_test_reg, y_pred_reg)

print(f"--- Evaluación Modelo de Regresión (Ridge) ---")
print(f"Root Mean Squared Error (RMSE): {rmse:.4f}")
print(f"R-cuadrado (R²): {r2:.4f}")
--- Evaluación Modelo de Regresión (Ridge) ---
Root Mean Squared Error (RMSE): 0.1904
R-cuadrado (R²): 0.4166

Interpretación:

  • RMSE: Mide el error promedio de predicción en la misma escala que el target. Un valor más bajo es mejor.
  • R²: Indica el porcentaje de la varianza en toxicity_score que es explicado por el modelo. Un valor más cercano a 1 es mejor. (Es común que los modelos de texto para regresión tengan un R² moderado).

Visualización (Real vs. Predicho)

plot_df = pd.DataFrame({'Real': y_test_reg, 'Predicho': y_pred_reg})

# Scatter plot con Altair
scatter = alt.Chart(plot_df).mark_circle(size=60, opacity=0.5).encode(
    x=alt.X('Real', title='Valor Real de Toxicidad'),
    y=alt.Y('Predicho', title='Valor Predicho de Toxicidad'),
    tooltip=['Real', 'Predicho']
).properties(
    title='Regresión: Valor Real vs. Predicho'
)

# Línea de referencia (perfecta predicción)
line = alt.Chart(pd.DataFrame({'x': [0, 1], 'y': [0, 1]})).mark_line(color='red', strokeDash=[3,3]).encode(
    x='x',
    y='y'
)

display(scatter + line)

Valores Reales vs. Predichos (Regresión)

Tarea 2: Evaluación de Clasificación (LogisticRegression)

Evaluamos qué tan bien nuestro modelo distingue entre tweets “tóxicos” y “no tóxicos”.

Reporte de Clasificación y Matriz de Confusión

print("--- Evaluación Modelo de Clasificación (LogisticRegression) ---")
print("\nReporte de Clasificación:")
print(classification_report(y_test_class, y_pred_class, target_names=['No Tóxico (0)', 'Tóxico (1)']))

# Matriz de Confusión
print("\nMatriz de Confusión:")
cm = confusion_matrix(y_test_class, y_pred_class)
disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=['No Tóxico', 'Tóxico'])

fig, ax = plt.subplots(figsize=(7, 7))
disp.plot(ax=ax, cmap='Blues', colorbar=False)
plt.title('Matriz de Confusión')
plt.show()
--- Evaluación Modelo de Clasificación (LogisticRegression) ---

Reporte de Clasificación:
               precision    recall  f1-score   support

No Tóxico (0)       0.90      0.91      0.90       276
   Tóxico (1)       0.56      0.54      0.55        61

     accuracy                           0.84       337
    macro avg       0.73      0.72      0.73       337
 weighted avg       0.84      0.84      0.84       337


Matriz de Confusión:

Matriz de Confusión (Clasificación)

Interpretación (Clase Tóxica - 1):

  • Precision: De todos los tweets que el modelo etiquetó como tóxicos, ¿cuántos realmente lo eran?
  • Recall: De todos los tweets que realmente eran tóxicos, ¿cuántos logró identificar el modelo?
  • Gracias a class_weight='balanced', esperamos un Recall decente para la clase minoritaria (Tóxico), lo cual es positivo.

Curva ROC-AUC

auc_score = roc_auc_score(y_test_class, y_pred_proba_class)
print(f"\nÁrea bajo la Curva ROC (AUC-ROC): {auc_score:.4f}")

fig, ax = plt.subplots(figsize=(8, 7))
RocCurveDisplay.from_predictions(y_test_class, y_pred_proba_class, ax=ax, name='Logistic Regression')
ax.plot([0, 1], [0, 1], linestyle='--', color='r', label='Azar (AUC = 0.5)')
plt.title('Curva ROC')
plt.legend()
plt.show()

Área bajo la Curva ROC (AUC-ROC): 0.7954

Curva ROC (Clasificación)

Interpretación: El score AUC-ROC mide la habilidad del modelo para discriminar entre las dos clases. Un valor de 1.0 es perfecto, 0.5 es aleatorio.

Tarea 3: Evaluación de Clustering (KMeans)

Evaluamos los grupos (clusters) encontrados. Como es no supervisado, la evaluación es más cualitativa.

Análisis Cuantitativo (Relación con Toxicidad)

Vemos el score de toxicidad promedio en cada cluster que encontramos.

# Usamos df_clean que tiene la columna 'cluster'
cluster_analysis = df_clean.groupby('cluster')['toxicity_score'].describe()
print(f"Análisis de 'toxicity_score' por Cluster (k={k_optimo}):")
display(cluster_analysis)

# Visualización
plt.figure(figsize=(10, 6))
sns.boxplot(data=df_clean, x='cluster', y='toxicity_score')
plt.title('Distribución de Toxicidad por Cluster')
plt.show()
Análisis de 'toxicity_score' por Cluster (k=3):
count mean std min 25% 50% 75% max
cluster
0 1156.0 0.263967 0.246424 0.001940 0.033511 0.199981 0.437768 0.939145
1 88.0 0.255634 0.252675 0.003393 0.017357 0.171552 0.466188 0.862967
2 103.0 0.139157 0.169547 0.002026 0.007257 0.047852 0.254629 0.710546

Interpretación: Este es el análisis clave. ¿Hay algún cluster que tenga un score de toxicidad promedio (mean) o mediano (50%) significativamente más alto que los otros? Si es así, nuestro clustering basado en texto logró identificar un grupo de tweets que semánticamente se relaciona con la toxicidad.

Análisis Cualitativo (Ejemplos de Tweets)

Inspeccionamos tweets aleatorios de cada cluster para entender su “tema”.

pd.set_option('display.max_colwidth', 200)

print("\n--- Ejemplos de Tweets por Cluster ---")
for i in range(k_optimo):
    print(f"\n===== CLUSTER {i} (Toxicidad media: {cluster_analysis.loc[i, 'mean']:.3f}) =====")
    sample_tweets = df_clean[df_clean['cluster'] == i]['content'].sample(3, random_state=42)
    for tweet in sample_tweets:
        print(f"  - {tweet}\n")

--- Ejemplos de Tweets por Cluster ---

===== CLUSTER 0 (Toxicidad media: 0.264) =====
  - @DanielNoboaOk con este gobierno ya no hay pan para tanto ladrón sigamos adelante con noboa

  - @DanielNoboaOk la miseria la vivimos con ud.

  - @leonorc2106 @LuisaGonzalezEc Jajajajajjaja


===== CLUSTER 1 (Toxicidad media: 0.256) =====
  - @DanielNoboaOk @DiegoBorjaPC Yo no le creo a un presidente de cartón. Todo 5💙

  - @DanielNoboaOk @DiegoBorjaPC Lávate el hocico presidente de cartón,habla la verdad y cómo son las cosas! Cómo han sido los tiempos y cómo han pasado las cosas,si no entiendes cómo son los procesos de contratación qué haces de presidente ignorante!Borja no debería ser candidato es correcto al tener un vínculo

  - @LuisaGonzalezEc Pero tus panas asambleístas no apoyan ninguna ley para castigar a los delincuentes,por eso la delincuencia no para. Así que no te hagas la que te duele. Que eso te gusta para hacer quedar mal al presidente. Así, que a tus borregos con ese falso dolor


===== CLUSTER 2 (Toxicidad media: 0.139) =====
  - @Santhy91 @LuisaGonzalezEc Luisa la sumisa desdolarizadora 
Los mediocres votan por esta tipeja que apoya al GENOCIDA DICTADOR maduro

  - ¡Respeto a la mujer!
Salvo que algún líder de RC5 la quiera de esclava sexual. Ahí si se jode. Los líderes sobre todas las cosas.
Casualmente es el primer mandamiento: Amarás a *tus líderes con todo tu corazón y con toda tu alma y con toda tu mente.

*Dónde tus líderes serán los que Correa te mande y ordene.

Ya maduren, RC5 es el mismo proyecto que mantiene a Cuba, Nicaragua y Venezuela esclavas del socialismo que quieren votar.

Noboa es una bestia pero Luisa es parte de una organización criminal internacional, a Noboa podemos hacerle un proceso revocatorio del mandato, con Luisa no tendremos nunca otro @Lenin
Votar nulo no ayuda, así lo calcularon desde la época de Chávez.

  - @LuisaGonzalezEc @DianaAtamaint @cnegobec @FFAAECUADOR Chúpate la Plata y el lunes te vas a Venezuela a conseguir Trabajo Luisa !!!!!! https://t.co/iNMBdXotpb

7. Conclusiones

Reflexión final sobre los resultados del proyecto.

  1. Calidad de Datos y EDA: El dataset, aunque pequeño (1500 registros), fue suficiente para un pipeline completo. El hallazgo clave del EDA fue el sesgo extremo en toxicity_score, lo cual impactó directamente la estrategia de clasificación, haciendo necesario el uso de class_weight='balanced'.

  2. Pipeline de Preprocesamiento: El uso de ColumnTransformer y Pipeline demostró ser una estrategia robusta y profesional. Permitió encapsular toda la lógica de transformación (escalado numérico, OneHot categórico y TF-IDF con lematización personalizada) en un solo objeto, evitando fugas de datos y simplificando el entrenamiento.

  3. Rendimiento de Regresión: Como se esperaba, predecir un score de toxicidad fino (regresión) a partir de texto y metadatos es difícil. El modelo Ridge probablemente arrojó un R² modesto, indicando que las features lineales (TF-IDF + metadatos) no capturan toda la complejidad semántica que define un score de toxicidad numérico.

  4. Rendimiento de Clasificación: El modelo LogisticRegression para la tarea binaria (Tóxico / No Tóxico) probablemente funcionó mucho mejor (evaluado por AUC-ROC y F1-Score). La lematización y el manejo del desbalanceo de clases fueron cruciales. Los resultados de la matriz de confusión (específicamente el recall de la clase “Tóxico”) validan la estrategia.

  5. Patrones de Clustering: El análisis de KMeans basado solo en texto (TF-IDF) fue revelador. Al comparar el score de toxicidad promedio de los clusters encontrados, pudimos validar si ciertos “temas” o estilos de lenguaje (capturados por los clusters) se correlacionan con niveles más altos de toxicidad.

Pasos Futuros y Mejoras

  • Modelos Avanzados: Para mejorar el rendimiento, especialmente en regresión, el siguiente paso sería usar modelos basados en embeddings (como Word2Vec o FastText) o Transformers (como BETO, la versión en español de BERT).
  • Hyperparameter Tuning: Podríamos usar GridSearchCV o RandomizedSearchCV sobre los pipelines completos para encontrar los mejores hiperparámetros (ej. alpha en Ridge, C en LogisticRegression, o max_features y ngram_range en TfidfVectorizer).
  • Feature Engineering: Crear features adicionales, como el análisis de sentimiento (polaridad), la cantidad de mayúsculas, o la longitud promedio de las palabras, podría añadir más señal a los modelos.
  • Estrategia de Target: Experimentar con diferentes umbrales o una clasificación multiclase (ej. usando cuartiles como ‘bajo’, ‘moderado’, ‘alto’) podría ofrecer insights diferentes.